<?php
/**
 * This file defines the {@see \BF\Net\Url} class.
 *
 * @author     Kado <ulfikado@gmail.com>
 * @category   Beluga PHP5.3+ Framework
 * @package    BF\Net
 * @since      2015-01-31 00:29
 * @subpackage Core
 * @version    0.1
 */

namespace BF\Net
{

   /**
    * Splits a URL string to all usable elements.
    *
    * <code>$Scheme://$AuthUser:$AuthPass@$Domain/$Path?$Query#$Anchor</code>
    *
    *
    * @property string  $Scheme   Gets or sets the current uri scheme, like 'http'. (see SCHEME_* class constants)
    * @property integer $Port     Gets or sets the current uri port number, if defined.
    * @property string  $AuthUser Gets the current uri auth user info (if defined its a security issue)
    * @property string  $AuthPass Gets the current uri auth password info (if defined its a security issue)
    * @property \BF\Net\Domain $Domain The whole domain part of the url
    * @property string  $Path     The path part of the URL, always beginning with the '/' sign.
    * @property array   $Query    The query string elements part of the URI, as a associative array.
    * @property string  $Anchor   The Anchor part of the URI, beginning with the '#' character, if defined.
    * @property-read string $QueryString The Query attributes as a string, beginning with the '?' character
    * @since      v1.0.0
    */
   class Url
   {

      # <editor-fold defaultstate="collapsed" desc=" - - -   C O N S T A N T S   - - - - - - - - - - - - - - - - - - - - - - - - - - -">

      /**
       * The 'http' scheme.
       */
      const SCHEME_HTTP = 'http';

      /**
       * The 'https' scheme.
       */
      const SCHEME_SSL  = 'https';

      /**
       * The 'ftp' scheme.
       */
      const SCHEME_FTP  = 'ftp';

      # </editor-fold>

      # <editor-fold defaultstate="collapsed" desc=" - - -   P R O T E C T E D   F I E L D S   - - - - - - - - - - - - - - - - - - - -">

      protected $properties;

      # </editor-fold>

      # <editor-fold defaultstate="collapsed" desc=" - - -   P R I V A T E   F I E L D S   - - - - - - - - - - - - - - - - - - - - - -">

      /**
       * @TODO: Missing $openRedirectionURLs description
       *
       * @var \BF\Net\Url[]
       */
      private $openRedirectionURLs = array();

      /**
       * @TODO: Missing $lastOpenRedictectResultPoints description
       *
       * @var integer
       */
      private $lastOpenRedictectResultPoints = 0;

      # </editor-fold>

      # <editor-fold defaultstate="collapsed" desc=" - - -   P U B L I C   S T A T I C   F I E L D S   - - - - - - - - - - - - - - - -">

      /**
       * This scheme is used if none is defined. (default='http')
       *
       * @var string
       */
      public static $FallbackScheme = 'http';

      # </editor-fold>

      # <editor-fold defaultstate="collapsed" desc=" - - >   P R O T E C T E D   C O N S T R U C T O R   - - - - - - - - - - - - - - -">

      /**
       * Inits a new instance.
       *
       * @param array $properties
       */
      protected function __construct( array $properties )
      {
         $this->properties = $properties;
      }

      # </editor-fold>

      # <editor-fold defaultstate="collapsed" desc=" - - -   P U B L I C   M E T H O D S   - - - - - - - - - - - - - - - - - - - - - -">

      /**
       * Returns if URL contains some login data usable with AUTHTYPE BASIC. This is a security issue!
       *
       * @return boolean
       */
      public function hasLoginData()
      {
          return ! empty( $this->properties[ 'authuser' ] ) || ! empty( $this->properties[ 'authpass' ] );
      }

      /**
       * returns, if the current URL points to a IP addresse without using some host name, etc.
       *
       * @return boolean
       */
      public function useIpAddress()
      {
         return $this->properties[ 'domain' ]->IsIPAddress;
      }

      /**
       * Returns if a port is used that points not to default port of current scheme/protocol.
       * If not explicit port is defined it always returns TRUE.
       *
       * @return boolean
       */
      public function useAssociatedPort()
      {
         if ( $this->properties[ 'port' ] < 1 )
         {
            return true;
         }
         switch ( $this->properties[ 'scheme' ] )
         {
            case self::SCHEME_HTTP:
               return ( $this->properties[ 'port' ] === 80);
            case self::SCHEME_SSL:
               return ( $this->properties[ 'port' ] === 443 );
            case self::SCHEME_FTP:
               return ( $this->properties[ 'port' ] === 21 );
            default :
               return false;
         }
         return false;
      }

      /**
       * Returns, if current URL uses a known web scheme. Known web schemes (protocols) are 'http', 'https' and 'ftp'.
       *
       * @return boolean
       */
      public function hasKnownWebscheme()
      {
         return (bool) \preg_match( '~^(https?|ftp)$~', $this->properties[ 'scheme' ] );
      }

      /**
       * Returns if the current URL points to a URL shortener service.
       *
       * @return boolean
       */
      public function isUrlShortenerAddress()
      {
         return $this->properties[ 'domain' ]->IsUrlShortener;
      }

      /**
       * Gets the real url behind a shortener URL, if current URL points to a URL shortener service.
       *
       * @return \BF\Net\Url Returns the real URL, or FALSE.
       */
      public function extractUrlShortenerTarget()
      {
         if ( ! $this->isUrlShortenerAddress() )
         {
            return false;
         }
         try
         {
            $data = \get_headers( (string) $this, 1 );
            if ( ! isset( $data[ 'Location' ] ) )
            {
               return false;
            }
            if ( false === ( $lurl = \BF\Net\Url::Parse( $data[ 'Location' ] ) ) )
            {
               return false;
            }
            return $lurl;
         }
         catch ( \Exception $ex ) { $ex = null; return false; }
      }

      /**
       * Returns, if the current url contains some GET parameter(s) that are able to be used for the
       * "Open Rediction Bug" (OR-Bug).
       *
       * The OR-Bug can be used to redirect from URL currently not known as bad url, to some bader spaming url
       * (or something else)
       *
       * For doing it, its required to have a bad programmed web application that accepts unchecked GET parameter
       * used to define a redirection target URL. Like
       *
       * <code>http://example.com/?redirect=http%3A%2F%2Fexample.net%2Fbadurl</code>
       *
       * If a possible open redirection URL was found, it is stored as a separate \BF\Net\Url instance and can
       * be accessed by {@see \BF\Net\Url::getOpenRedirectionURLs()}.
       *
       * If it really works as a usable open redirection bug, can only be checked, if a real request is send, to
       * check if the redirection works. If you want to real check it out you can use the
       * {@see \BF\Net\Url::checkForOpenRedirect()} method. But its important to read its documentation before!
       *
       * @param  int &$resultPoints returns the max. probability of a URL injection (0-10 and > 5 means its possible)
       * @return boolean
       */
      public function isPossibleOpenRedirect( &$resultPoints )
      {

         // We are working with result points (> 4 returns TRUE)
         // to getting informations about the badness (possibility of Openredirect) of a url
         $resultPoints = 0;

         if ( \count( $this->openRedirectionURLs ) > 0 )
         {
            // If there are already some check results use it
            $resultPoints = $this->lastOpenRedictectResultPoints;
            // and return the existing result
            return $resultPoints > 4;
         }

         if ( ! \is_array( $this->properties[ 'query' ] ) || \count( $this->properties[ 'query' ] ) < 1 )
         {
            // If no query parameters are defined every thing is OK and we dont have to do more checks
            return false;
         }

         // Init array to hold some founded param names key) and associated resultpoints
         $founds  = array();
         $highest = 0;

         // OK lets check all GET/query parameters
         foreach ( $this->properties[ 'query' ] as $key => $value )
         {

            if ( ! \is_string( $value ) )
            {
               // The query parameter value is not a string (@todo: why not!) go to next one.
               continue;
            }

            if ( ! \preg_match( '~^(https?|ftps?)://~i', $value ) )
            {
               // The query parameter value is not a url, go to next one.
               continue;
            }

            // Getting the URL instance to do some more detailed checks.
            $url = new \BF\Net\Url( $value );

            if ( ! ( $url->properties[ 'domain' ] instanceof \BF\Net\Domain ) )
            {
               // There is no usable domain defined, go to next one.
               continue;
            }

            // Do some Domain specific checks

            if ( ( ( (string) $url->properties[ 'domain' ] ) === ( (string) $this->properties[ 'domain' ] ) ) )
            {
               // If it points to the same domain its not problem, go to next one.
               continue;
            }

            if ( ( ( (string)$url->properties[ 'domain' ]->SLD ) === ( (string)$this->properties[ 'domain' ]->SLD ) ) )
            {
               // If
               $founds[ $key ] = 4;
            }
            else
            {
               $founds[ $key ] = 5;
            }
            if ( \preg_match( '~^(url|redir|addr|loc)~i', $key ) )
            {
               $founds[ $key ] += 2;
            }
            if ( ! $url->useAssociatedPort() )
            {
               // Make it bader if no associated Port is used
               ++$founds[ $key ];
            }
            if ( $url->properties[ 'domain' ]->IsIPAddress )
            {
               // Make it bader if a IP address is used.
               ++$founds[ $key ];
            }
            if ( $url->hasLoginData() )
            {
               // Make it bader if a login data are defined by url
               ++$founds[ $key ];
            }
            if ( $url->isUrlShortenerAddress() )
            {
               // Make it more bader if url points to a URL shortener service
               $founds[ $key ] += 2;
            }
            if ( $founds[ $key ] > 10 )
            {
               // Normalize to a maximum value of 10.
               $founds[ $key ] = 10;
            }
            if ( $founds[ $key ] > $highest )
            {
               // Remember the highest value
               $highest = $founds[ $key ];
            }
            if ( $founds[ $key ] > 4 )
            {
               $this->openRedirectionURLs[ $key ] = $url;
            }
         }

         if ( $highest > 4 )
         {
            $resultPoints                        = $highest;
            $this->lastOpenRedictectResultPoints = $highest;
            return true;
         }
         $this->lastOpenRedictectResultPoints = 0;
         return false;
      }

      /**
       * Returns all possible open redirection URLs, defined if {@see \BF\Net\Url::isPossibleOpenRedirect()}
       * returns TRUE.
       *
       * @return \BF\Net\Url[]
       */
      public function getOpenRedirectionURLs()
      {
         return $this->openRedirectionURLs;
      }

      /**
       * Checks, if possible open rediction bug URLs are defined, if one of it its a real open redirection usage.
       *
       * Attention. It sends a real request to each URL. Dont use it inside youre main web application because it blocks
       * it as long if it gets a answer. Maybe better use it in cron jobs or inside a very low frequenced area!
       *
       * How it works is easy. All you need is a url and its well known output.
       *
       * If we replace the possible redirection URL inside the current url with the URL where we know the output and
       * it redirects to the url with the known output, the bug is used.
       *
       * @param  string  $urlForTestContents The URL with the known output
       * @param  string  $testContents       The known output of $urlForTestContents (or a regex if $useAsRegex is TRUE)
       * @param  boolean $useAsRegex         Should $testContents be used as a regular expression?
       * @return boolean
       */
      public function checkForOpenRedirect( $urlForTestContents, $testContents, $useAsRegex = false )
      {

         if ( \count( $this->openRedirectionURLs ) < 1 )
         {
            // If no open redirection URLs was found by isPossibleOpenRedirect(…) we are already done here
            return false;
         }

         // Remember the current query parameters
         $oldQuery = $this->properties[ 'query' ];

         // Getting the query keys
         $keys = \array_keys( $this->openRedirectionURLs );

         // Loop the query keys and assign the replacement url to this query
         foreach ( $keys as $key )
         {
            $this->properties[ 'query' ][ $key ] = $urlForTestContents;
         }

         // Adjust get_headers() to send a HEAD request
         \stream_context_set_default(
            array(
               'http' => array( 'method' => 'HEAD' )
            )
         );

         // Getting th URL string to call
         $url = (string) $this;
         // Init state flag
         $handleHeaders = true;

         // OK now we can reassign the origin headers
         $this->properties[ 'query' ] = $oldQuery;

         if ( false === ( $headers = \get_headers( $url, 1 ) ) )
         {
            // If the head request fails get headers from GET request
            \stream_context_set_default(
               array(
                  'http' => array( 'method' => 'GET' )
               )
            );
            // Get header by GET request
            if ( false === ( $headers = \get_headers( $url, 1 ) ) )
            {
               $handleHeaders = false;
            }
         }
         else
         {
            // reset get_header to use defaut GET request
            \stream_context_set_default(
               array(
                  'http' => array( 'method' => 'GET' )
               )
            );
         }

         if ( $handleHeaders && \count( $headers ) > 0 )
         {

            // There are usable headers in response, handle it

            // Make header keys to lower case
            $headers = \array_change_key_case( $headers, \CASE_LOWER );

            if ( isset( $headers[ 'location' ] ) && ( $urlForTestContents === $headers[ 'location' ] ) )
            {
               // Location header to defined URL is defined. Now we know its a open redirection bug usage
               return true;
            }

            if ( isset( $headers[ 'refresh' ] ) && ( \BF\str_contains( $headers[ 'refresh' ], $urlForTestContents ) ) )
            {
               // Refresh header to defined URL is defined. Now we know its a open redirection bug usage
               return true;
            }

         }

         // We can not work with headers because they dont gives us the required informations.

         // Get the data from URL to check
         $resultContents = \file_get_contents( $url );

         if ( $useAsRegex )
         {
            try { return (bool) \preg_match( $testContents, $resultContents ); }
            catch ( \Exception $ex ) { $ex = null; }
         }

         $regex = '~<meta\s+http-equiv=(\'|")?refresh(\'|")?\s+content=(\'|")\d+;\s*url='
                . \preg_quote( $url )
                . '~i';
         if ( \preg_match( $regex, $resultContents ) )
         {
            return true;
         }

         return $testContents == $resultContents;
      }

      /**
       * Magic getter
       *
       * @param  string $name Field/Property name.
       * @return string
       */
      public function __get( $name )
      {

         // Switch name to lower case
         $lowerName = \strtolower( $name );

         switch ( $lowerName )
         {
            case 'scheme':
               if ( empty( $this->properties[ $lowerName ] ) )
               {
                  $this->properties[ $lowerName ] = self::$FallbackScheme;
               }
               return $this->properties[ $lowerName ];
            case 'port':
               if ( $this->properties[ $lowerName ] > 0 )
               {
                  return $this->properties[ $lowerName ];
               }
               if ( empty( $this->properties[ 'scheme' ] ) )
               {
                  $this->properties[ 'scheme' ] = self::$FallbackScheme;
               }
               switch ( \strtolower( $this->properties[ 'scheme' ] ) )
               {
                  case 'http' : return 80;
                  case 'https': return 443;
                  case 'ftp'  : return 21;
                  default     : return 0;
               }
            case 'path':
               if ( \strlen( \trim( $this->properties[ $lowerName ] ) ) < 1 )
               {
                  return '/';
               }
               if ( !\BF\str_startswith( $this->properties[ $lowerName ], '/' ) )
               {
                  return '/' . $this->properties[ $lowerName ];
               }
               return $this->properties[ $lowerName ];
            case 'querystring':
               if ( \count( $this->properties[ 'query' ] ) < 1 )
               {
                  return '';
               }
               return '?' . \http_build_query( $this->properties[ 'query' ] );
            case 'anchor':
               if ( \strlen( \trim( $this->properties[ $lowerName ] ) ) < 1 )
               {
                  return '';
               }
               if ( !\BF\str_startswith( $this->properties[ $lowerName ], '#' ) )
               {
                  return '#' . $this->properties[ $lowerName ];
               }
               return $this->properties[ $lowerName ];
            default:
               return isset( $this->properties[ $lowerName ] ) ? $this->properties[ $lowerName ] : false;
         }

      }

      public function __set( $name, $value )
      {

         // Switch name to lower case
         $lowerName = \strtolower( $name );

         switch ( $lowerName )
         {
            case 'scheme':
               if ( ! \preg_match( '~^[a-z]{3,7}$~i', $value ) )
               {
                  throw new \BF\ArgumentFormatException( $name, $value, 'Invalid URL scheme!' );
               }
               $this->properties[ $lowerName ] = $value;
               break;
            case 'port':
               if ( \is_null( $value ) )
               {
                  $this->properties[ $lowerName ] = 0;
                  break;
               }
               if ( ! \BF\TypeTool::IsInteger( $value ) )
               {
                  throw new \BF\ArgumentFormatException( $name, $value, 'Invalid URL port number!' );
               }
               $intValue = \intval( $value );
               if ( $intValue < 0 )
               {
                  $intValue = 0;
               }
               $this->properties[ $lowerName ] = $intValue;
               break;
            case 'authuser':
            case 'authpass':
            case 'authpassword':
               if ( empty( $value ) )
               {
                  $this->properties[ $lowerName ] = '';
                  break;
               }
               $this->properties[ $lowerName ] = $intValue;
               break;
            case 'domain':
               if ( ! ( $value instanceof \BF\Net\Domain ) )
               {
                  throw new \BF\ArgumentException( $name, $value, 'Invalid URL domain part definition!' );
               }
               $this->properties[ $lowerName ] = $value;
               break;
            case 'path':
               if ( \is_null( $value ) || ! \is_string( $value ) )
               {
                  $this->properties[ $lowerName ] = '/';
                  break;
               }
               if ( ! \preg_match( '#^[a-z0-9_.:,@%/+*~$-]+$#i', $value ) )
               {
                  throw new \BF\ArgumentFormatException( $name, $value, 'Invalid URL path part definition!' );
               }
               $this->properties[ $lowerName ] = $value;
               break;
            case 'query':
               if ( \is_null( $value ) || ( ! \is_string( $value ) && ! \is_array( $value ) ) )
               {
                  $this->properties[ $lowerName ] = '';
                  break;
               }
               if ( \is_string( $value ) )
               {
                  $this->properties[ 'query' ] = $this->parseQuery( $value );
                  break;
               }
               $this->properties[ 'query' ] = $value;
               break;
            case 'anchor':
               if ( empty( $value ) )
               {
                  $this->properties[ $lowerName ] = '';
                  break;
               }
               if ( ! \preg_match( '~^#?[a-z_-][a-z0-9_.-]*$~i', $value ) )
               {
                  throw new \BF\ArgumentFormatException( $name, $value, 'Invalid URL anchor part definition!' );
               }
               $this->properties[ $lowerName ] = '#' . \ltrim( $value, '#' );
               break;
         }

      }

      /**
       * The magic string cast method.
       *
       * @return string
       */
      public function __toString()
      {

         $url = $this->Scheme . '://';

         if ( $this->hasLoginData() )
         {
            if ( ! empty( $this->properties[ 'authuser' ] ) )
            {
               $url .= $this->properties[ 'authuser' ];
            }
            $url .= ':';
            if ( ! empty( $this->properties[ 'authpass' ] ) )
            {
               $url .= $this->properties[ 'authpass' ];
            }
            $url .= '@';
         }

         $url .= $this->properties[ 'domain' ]->toString();

         if ( $this->properties[ 'port' ] > 0 )
         {
            $url .= ( ':' . $this->properties[ 'port' ] );
         }

         $url .= ( $this->Path . $this->Anchor . $this->QueryString );

         return $url;

      }

      # </editor-fold>

      # <editor-fold defaultstate="collapsed" desc=" - - -   P U B L I C   S T A T I C   M E T H O D S   - - - - - - - - - - - - - - -">

      /**
       * Finds all valid URLs inside the defined text and returns it as a string array.
       *
       * @param  string $text The text where the URLs should be extracted from
       * @param  array  $ignoreDomains Numeric indicated array, defining domains that should be ignored
       * @return array
       */
      public static function FindAllUrls( $text, array $ignoreDomains = array() )
      {
         $result  = array();
         $matches = null;
         \preg_match_all( \BF\RX::URL_FIND, $text, $matches );
         if ( \count( $matches ) > 0 && \count( $matches[ 0 ] ) > 0 )
         {
            foreach ( $matches[ 0 ] as $match )
            {
               if ( false === ( $url = \BF\Net\Url::Parse( $match ) ) )
               {
                  continue;
               }
               if ( \in_array( $url->Domain->toString(), $ignoreDomains )
                 || \in_array( $url->Domain->SLD->toString(), $ignoreDomains ) )
               {
                  continue;
               }
               $result[] = $match;
            }
         }
         \preg_match_all( '~(?<=\A|\s)www\.([a-z0-9][a-z0-9_./+%?&#-]+)~i', $text, $matches );
         if ( \count( $matches ) > 0 && \count( $matches[ 0 ] ) > 0 )
         {
            foreach ( $matches[ 0 ] as $match )
            {
               if ( false === ( $url = \BF\Net\Url::Parse( $match ) ) )
               {
                  continue;
               }
               if ( \in_array( $url->Domain->toString(), $ignoreDomains )
                 || \in_array( $url->Domain->SLD->toString(), $ignoreDomains ) )
               {
                  continue;
               }
               $result[] = 'http://' . $match;
             }
         }
         return $result;
      }

      /**
       * Parses a URL strin and returns the resulting {@see \BF\Net\Url} instance. If parsing fails, it returns
       * boolean FALSE.
       *
       * @param  string $urlString   The URL string to parse
       * @return \BF\Net\Url         Returns the URL instance, or FALSE if parsing fails.
       */
      public static function Parse( $urlString )
      {

         if ( ! \preg_match( '~^[^:]+://~i', $urlString )
           && ! \preg_match( '~^mailto:[a-z0-9_]~i', $urlString ))
         {
            // $urlString dont starts with a valid scheme => Append the fallback scheme.
            switch ( self::$FallbackScheme )
            {
               case 'mailto':
                  if ( false === \BF\Net\MailAddress::Parse( $urlString, false, false, true ) )
                  {
                     return false;
                  }
                  $urlString = 'mailto:' . $urlString;
                  break;
               default:
                  $urlString = self::$FallbackScheme . '://' . $urlString;
                  break;
            }
         }

         // Extract the URL informations
         $urlInfos = self::getUrlInfos( $urlString );

         if ( ! \is_array( $urlInfos ) || \count( $urlInfos ) < 1 )
         {
            // No arms => no cookies :-(
            return false;
         }

         // Switch the case of the array keys to lower case.
         $objectData = \array_change_key_case( $urlInfos, \CASE_LOWER );

         // The host must be defined!
         if ( empty( $objectData[ 'host' ] ) )
         {
            return false;
         }

         // Init the Properties
         $properties = array(
            'scheme' => self::$FallbackScheme,
            'port' => 0,
            'authuser' => '',
            'authpass' => '',
            'path' => '/',
            'query' => array(),
            'anchor' => '',
            'domain' => false
         );

         if ( isset( $objectData[ 'scheme' ] ) )
         {
            $properties[ 'scheme' ] = \strtolower( $objectData[ 'scheme' ] );
         }
         if ( isset( $objectData[ 'port' ] ) )
         {
            $properties[ 'port' ] = \intval( $objectData[ 'port' ] );
         }
         if ( isset( $objectData[ 'user' ] ) )
         {
            $properties[ 'authuser' ] = $objectData[ 'user' ];
         }
         if ( isset( $objectData[ 'pass' ] ) )
         {
            $properties[ 'authpass' ] = $objectData[ 'pass' ];
         }
         if ( isset( $objectData[ 'path' ] ) )
         {
            $properties[ 'path' ] = $objectData[ 'path' ];
         }
         if ( isset( $objectData[ 'query' ] ) )
         {
            $properties[ 'query' ] = self::parseQuery ( $objectData[ 'query' ] );
         }
         if ( isset( $objectData[ 'fragment' ] ) )
         {
            $properties[ 'anchor' ] = $objectData[ 'fragment' ];
         }
         if ( isset( $objectData[ 'host' ] ) )
         {
            $properties[ 'domain' ] = \BF\Net\Domain::Parse( $objectData[ 'host' ], false );
         }

         if ( ! ( $properties[ 'domain' ] instanceof \BF\Net\Domain ) )
         {
            // if no usable domain is defined, return FALSE
            return false;
         }

         return new \BF\Net\Url( $properties );

      }

      /**
       * UTF-8 aware parse_url() replacement.
       *
       * Returned values can use the following keys (all optionally):
       *
       * - scheme: e.g. http
       * - host
       * - port
       * - user
       * - pass
       * - path
       * - query: after the question mark ?
       * - fragment: after the hashmark #
       *
       * @return array Returns the resulting array, or FALSE, if parsing fails
       */
      public static function getUrlInfos( $url )
      {

         // Encode the URL
         $encUrl = \preg_replace_callback(
            '%[^:/@?&=#]+%usD',
            function ( $match )
            {
               return \urlencode( $match[ 0 ] );
            },
            $url
         );

         if ( false === ( $parts = \parse_url( $encUrl ) ) )
         {
            return false;
         }

         foreach( $parts as $name => $value )
         {
            $parts[ $name ] = \urldecode( $value );
         }

         return $parts;

      }

      # </editor-fold>

      # <editor-fold defaultstate="collapsed" desc=" - - -   P R I V A T E   S T A T I C   M E T H O D S   - - - - - - - - - - - - - -">

      private static function parseQuery( $query )
      {
         if ( empty( $query ) )
         {
            return array();
         }
         $elements = array();
         \parse_str( $query, $elements );
         if ( ! \is_array( $elements ) )
         {
            return array();
         }
         return $elements;
      }

      # </editor-fold>

   }

}

